Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a page on CSRF #38151

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open

Add a page on CSRF #38151

wants to merge 16 commits into from

Conversation

wbamberg
Copy link
Collaborator

@wbamberg wbamberg commented Feb 14, 2025

This PR adds a page on CSRF attacks.

It's potentially a replacement for https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention, and compared with that page:

  • explains in more concrete terms what a CSRF attack is and how it works
  • describes an alternative defense
  • describes in a bit more detail about the limitations of SameSite

@github-actions github-actions bot added Content:Security Security docs size/m [PR only] 51-500 LoC changed labels Feb 14, 2025
Copy link
Contributor

github-actions bot commented Feb 14, 2025

Preview URLs

External URLs (6)

URL: /en-US/docs/Web/Security/Attacks/CSRF
Title: Cross-site request forgery (CSRF)

(comment last updated: 2025-02-19 07:23:46)

@wbamberg wbamberg marked this pull request as ready for review February 14, 2025 19:28
@wbamberg wbamberg requested a review from a team as a code owner February 14, 2025 19:28
@wbamberg wbamberg requested review from hamishwillee and chrisdavidmills and removed request for a team February 14, 2025 19:28

In the example below, the user has previously signed into their bank, and the browser has stored a session cookie for the user. The page contains a {{htmlelement("form")}} element, which enables the user to transfer funds to another person. When the user submits the form, the browser sends a {{httpmethod("POST")}} request to the server, including the form data. If the user is signed in, the request includes the user's cookie. The server validates the cookie and performs the special action — in this case, transferring money:

![Diagram showing a user submitting a browser form, the browser then making a POST request to the server, and the server validating the request.](form-post.svg)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this just shorthand? The URL is an HTTP get

image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops, yes you are right, this ought to be fixed.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> 2b4c134

page-type: guide
---

In a cross-site request forgery (CSRF) attack, an attacker tricks the user or the browser into making an HTTP request to the target site. The request includes the user's credentials and causes the server to carry out some harmful action, thinking that the user intended it.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make it clear this is actually a cross site request, not XSS where its injecting malicious code in the same-site context? This is same comment as https://github.com/mdn/content/pull/38151/files#r1957450793 -

Copy link
Collaborator Author

@wbamberg wbamberg Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> 2b4c134

form.submit();
```

When the user visits the page, the browser submits the form to the bank's website. Because the user is signed into their bank, the request includes the user's real cookie, so the bank's server successfully validates the request, and transfers the funds:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A little bit of a mouthful. I've also used "If the request" - mostly because this is not the default (Lax is, usually), and the cookie isn't sent.

Suggested change
When the user visits the page, the browser submits the form to the bank's website. Because the user is signed into their bank, the request includes the user's real cookie, so the bank's server successfully validates the request, and transfers the funds:
When the user visits the page, the browser submits the form to the bank's website. If the request includes the user's sign-in cookie, the bank's server will successfully validate the request, and transfers the funds:

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the causality is important here. Because the user is signed into their bank, the request (might) include the user's session cookie for the bank.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did though put "include"->"may include" in 2b4c134.


![Diagram showing a CSRF attack in which a decoy page submits a POST request to the website for the user's bank.](csrf-form-post.svg)

There are other ways the attacker could issue a cross-site request forgery. For example, if the website uses a {{httpmethod("GET")}} request to carry out the action, then the attacker can avoid having to use a form at all, and can execute the attack by sending the user a link to a page that contains markup like this:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be worth saying here but it should be said in this doc (I'm working down, so apologies if you already covered it), but as above, GET should never be used for state changing requests for exactly this reason - too easy to hack, and you can't use CRSF tokens in this case.

Copy link
Collaborator Author

@wbamberg wbamberg Feb 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I feel like we should say this but I'm not sure where to put it exactly. I mentioned it in the bit about SameSite since it's very relevant to Lax.

exactly this reason - too easy to hack, and you can't use CRSF tokens in this case.

I struggle a bit with this. How practically is this really easier than the form method? No JS needed but so what?

And yes, at least for Django you need to avoid GET if you want to use CSRF tokens, and we could mention it there too, but that's really just a functional point about implementing that defense, and it's already covered in the Django docs for that (https://docs.djangoproject.com/en/5.1/ref/csrf/), so it doesn't feel like an "extra defense".

Mentioning it on its own section under "defenses" seems wrong too, since it's not a defense on its own.

I'll think some more about it.

Another problem with the `SameSite` attribute is that it protects you from requests from a different {{glossary("Site", "site")}}, not a different {{glossary("Origin", "origin")}}. This is a looser protection, because (for example) `https://foo.example.org` and `https://bar.example.org` are considered the same site, although they are different origins. Effectively, if you rely on same-site protection, you have to trust all your site's subdomains.

Even so, it is worth setting the `SameSite` attribute for sensitive cookies to `Strict` if you can, or `Lax` if you have to.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps a "Defense summary checklist" like that one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> 60a0284


### CSRF tokens

In this defense, when the server serves a page, it embeds an unpredictable value in the page, called the CSRF token. Then when the browser sends the state-changing request to the server, it includes the CSRF token in the HTTP request. The browser checks the token value and carries out the request only if it matches. Because an attacker can't guess the token value, they can't issue a successful forgery.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth commenting on the replay-preventing aspects of this? Something like

Suggested change
In this defense, when the server serves a page, it embeds an unpredictable value in the page, called the CSRF token. Then when the browser sends the state-changing request to the server, it includes the CSRF token in the HTTP request. The browser checks the token value and carries out the request only if it matches. Because an attacker can't guess the token value, they can't issue a successful forgery.
In this defense, when the server serves a page, it embeds an unpredictable value in the page, called the CSRF token. Then when the browser sends the state-changing request to the server, it includes the CSRF token in the HTTP request. The browser checks the token value and carries out the request only if it matches. Because an attacker can't guess the token value, they can't issue a successful forgery. Even if the attacker does somehow discover a token at a later point, the request can't be replayed if the token changes every time!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> 2b4c134


In this section we'll outline two alternative defenses against CSRF and a third practice which can be used to provide defense in depth for either of the other two.

- The first primary defense is to use _CSRF tokens_ embedded in the page. This method is especially appropriate if you're issuing state-changing requests from form elements, as in our example above.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps

Suggested change
- The first primary defense is to use _CSRF tokens_ embedded in the page. This method is especially appropriate if you're issuing state-changing requests from form elements, as in our example above.
- The first primary defense is to use _CSRF tokens_ embedded in the page. This method is the defacto-standard approach when issuing state-changing requests from form elements, as in our example above.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"de facto" is two words but I'm not that into Latin, so I've gone with "most common method" which I hope addresses the issue.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> 2b4c134


- The first primary defense is to use _CSRF tokens_ embedded in the page. This method is especially appropriate if you're issuing state-changing requests from form elements, as in our example above.

- The alternative defense is to ensure that state-changing requests are not _simple requests_, which enables them to rely on the browser's built-in cross-origin request blocking. This method is appropriate if you're issuing state-changing requests from JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}}.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps more direct?

Suggested change
- The alternative defense is to ensure that state-changing requests are not _simple requests_, which enables them to rely on the browser's built-in cross-origin request blocking. This method is appropriate if you're issuing state-changing requests from JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}}.
- The alternative defense is to ensure that state-changing requests are not CORS _simple requests_, ensuring that cross-origin requests are blocked by default. This method is appropriate if you're issuing state-changing requests from JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}}.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps "ensuring that" -> "which ensures that", to make it clear that the blocking is a consequence of the non-simpleness, and is not an extra thing people have to do?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps, though since I dislike the double-ensure use, perhaps

Suggested change
- The alternative defense is to ensure that state-changing requests are not _simple requests_, which enables them to rely on the browser's built-in cross-origin request blocking. This method is appropriate if you're issuing state-changing requests from JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}}.
- The alternative defense is to ensure that state-changing requests are not CORS _simple requests_, so that cross-origin requests are blocked by default. This method is appropriate if you're issuing state-changing requests from JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}}.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly accepted in 2b4c134, although I didn't add "CORS" because I want to put CORS in the back seat here. A lot of people think CORS is a security feature, and I want to make it as clear as possible that what we're talking about here is the default behavior, with no CORS.


In this defense, when the server serves a page, it embeds an unpredictable value in the page, called the CSRF token. Then when the browser sends the state-changing request to the server, it includes the CSRF token in the HTTP request. The browser checks the token value and carries out the request only if it matches. Because an attacker can't guess the token value, they can't issue a successful forgery.

For form submissions, the CSRF token is usually implemented as a hidden form field. For a JavaScript API like `fetch()`, the token might be placed in a cookie or embedded in the page, and the JavaScript extracts the value and sends it as an extra header.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps expand on this a little - i.e. the server.

Suggested change
For form submissions, the CSRF token is usually implemented as a hidden form field. For a JavaScript API like `fetch()`, the token might be placed in a cookie or embedded in the page, and the JavaScript extracts the value and sends it as an extra header.
For form submissions, the CSRF token is included in a hidden form field, so that on form submission its values is automatically sent back to the server for checking.
When using a JavaScript API like `fetch()` to submit a state-changing request, the token might be placed in a cookie or embedded in the page: the JavaScript extracts the value and sends it as an extra header.

" it as an extra header." - if it is always the same header (?) then that should be listed here. The other doc suggests X-CSRF-Token but I don't know if that is in any way standard.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK there is no standard here but happy to be corrected.

Comment on lines 78 to 82
By default, only simple requests can be sent cross-origin. The reason for allowing simple requests cross-origin is that these are the same sorts of requests that could already be made cross-origin using a `<form>` element, as in the example above. So it is assumed that websites must already implement CSRF protection against simple requests.

All other requests are by default not allowed cross-origin, so a CSRF attack would not succeed if the request is not simple.

So one CSRF defense is to ensure that state-changing requests are never simple requests. This of course means that a website can't use forms to issue them, so this strategy is usually applicable for a website that uses JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}} to issue state-changing requests.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is all useful but a bit awkward. I'm not sure "So it is assumed that websites must already implement CSRF protection against simple requests." is quite true either - I suspect it would be a non-simple request if there wasn't prior art meaning that too much of the web would breaki.

Suggested change
By default, only simple requests can be sent cross-origin. The reason for allowing simple requests cross-origin is that these are the same sorts of requests that could already be made cross-origin using a `<form>` element, as in the example above. So it is assumed that websites must already implement CSRF protection against simple requests.
All other requests are by default not allowed cross-origin, so a CSRF attack would not succeed if the request is not simple.
So one CSRF defense is to ensure that state-changing requests are never simple requests. This of course means that a website can't use forms to issue them, so this strategy is usually applicable for a website that uses JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}} to issue state-changing requests.
By default, only simple requests can be sent cross-origin, while non-simple requests are blocked cross-origin by default.
So one CSRF defense is to ensure that state-changing requests are never simple requests, and hence will be blocked.
Unfortunately `<form>` submissions, as in the example above, are simple requests.
Therefore this strategy is usually applicable for a website that uses JavaScript APIs like {{domxref("Window.fetch()", "fetch()")}} to issue state-changing requests.

Copy link
Collaborator Author

@wbamberg wbamberg Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it is assumed that websites must already implement CSRF protection against simple requests." is quite true either - I suspect it would be a non-simple request if there wasn't prior art meaning that too much of the web would break.

Well yes, AFAIK the reason cross-origin form requests are allowed is historical: by the time it was obvious that there was a problem here, there were too many legacy sites doing it for it to be fixed.

But because of that that we must assume people using forms are also implementing their own CSRF - we just have to assume that, because we can't fix it in the browser. This is covered quite explicitly in the CORS docs which I link in the previous para:

The motivation is that the <form> element from HTML 4.0 (which predates cross-site fetch() and XMLHttpRequest) can submit simple requests to any origin, so anyone writing a server must already be protecting against cross-site request forgery (CSRF). Under this assumption, the server doesn't have to opt-in (by responding to a preflight request) to receive any request that looks like a form submission, since the threat of CSRF is no worse than that of form submission.

So I'm not that keen on "unfortunately", since it implies that this is an unhappy accident, that simple requests just happen to include form submissions. But actually AIUI, by definition really, simple requests are the ones that forms can make (we might even call them "form-compatible requests"). If forms had made different requests, then the definition of a simple request would have been correspondingly different.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I made a different edit in 2b4c134.

This is obviously useful in cases where you want to accept requests from some other origins. However, it means that if your server sends an `Access-Control-Allow-Origin` response header including the sender's origin, and an `Access-Control-Allow-Credentials` response header, then the server is vulnerable to a CSRF attack from that origin.

### Defense in depth: SameSite cookies

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this. In particular the potential issues with Lax have never been explained to me before.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I feel this is vague and wish I had a concrete example of how an attacker could circumvent Lax. I just copied this bit from the spec but don't really understand it. Don't tell anyone that though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a big vague. I think only top level navigations with GET include cookies, so you'd be mostly fine if you don't do something dumb like make state-changing requests using URL params.

Here is some reading https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions#bypassing-samesite-lax-restrictions-using-get-requests

There are a few cases worth highlighting there - samesite isn't sameorigin.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I.e. I don't see how the popup case is a bypass or in some way an avenue for a same-site attack unless it gets to you maybe to open a subdomain - i.e. same-origin.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a big vague. I think only top level navigations with GET include cookies, so you'd be mostly fine if you don't do something dumb like make state-changing requests using URL params.

Seems like it's not just GET, but is only safe methods: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-02#section-5.5. And yes, the other bit of the spec does say SameSite gives you good protection if you enforce the use of unsafe methods like POST. So perhaps we can use this as a place to say don't issue state-changing requests using unsafe methods, and here's a reason why.

And if you do only use unsafe methods, then SameSite is a reasonable defense, except for the samesite != sameorigin thing.

Here is some reading https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions#bypassing-samesite-lax-restrictions-using-get-requests

Ah, that's a helpful link, yes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, - safe methods are this list:

"Safe" HTTP methods include "GET", "HEAD", "OPTIONS", and "TRACE", as
defined in Section 4.2.1 of [RFC7231].

So the methods you might use for changing something is GET. There is another note in the spect that safe method implementations should be kept idempotent.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I pushed 5a6d295 to go into some more detail on SameSite issues.

Copy link
Collaborator

@hamishwillee hamishwillee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW I like it a lot. It makes some of the things that aren't all that clear in https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/CSRF_prevention more clear.

Copy link
Contributor

@chrisdavidmills chrisdavidmills left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, @wbamberg. I've read through it, and it reads well and makes sense. I won't review the language in detail, as Hamish has already done that. I can give it a final language review after you've responded to his comments if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Content:Security Security docs size/m [PR only] 51-500 LoC changed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants